Stop shots and spawns from eroding the river banks - #3
Merged
Conversation
get_course_background_byte_indexed was orphaned by the run-fill rewrite of rebuild_block_bitmap_row, which emits the five contiguous terrain runs directly instead of classifying all thirty-two columns; its comment there no longer needs to name the routine it replaced. calc_river_center_col has no callers left, and timex_next_attribute_row was an instruction-for-instruction duplicate of timex_advance_object_row_fast. The image size is unchanged because the page-aligned course tables in state.asm absorb the freed bytes as padding; the removed symbols are gone from the map file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepare_transition_old_projectile_x masked the pixel offset twice and branched on it being zero, but a zero offset cannot equal seven, so that branch only reached the same width-1 result the cp 7 test already gives. The routine now mirrors prepare_transition_new_projectile_x exactly. Verified two ways: the emitted code for the two routines is byte-identical apart from the low bytes of the four destination addresses, and simulating the previous and current logic over all 256 inputs gives the same width and column for every one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The count=255 marker and the complete edge renderer behind it read like a working case for complex terrain, but the generator cannot reach them: a bank edge moves at most four pixels per block, and an island opens one byte wide and grows or tapers one byte per side per step, so consecutive blocks never differ in the 16 byte pairs the delta list holds. That leaves render_v3_row_indexed with one live caller, bridge repair, and since update_course_feature clears the island on every block unless a fork is in progress, its island half never runs at all. The dirty_ prefix on those labels invites the opposite conclusion, so say so at the routine itself: the scroll pass replays deltas in dirty_delta_replay and never enters here. Kept rather than deleted so a future wider terrain feature degrades into the complete renderer instead of drawing a truncated delta. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A two-pixel shot was drawn by storing its mask raw and cleaned up by writing plain water, both of which assume the whole screen byte is river. It is not: bullet_x is latched when the shot is fired while the river keeps narrowing above it, and bullet_hits_background only asks whether the two lit pixels meet land, so a byte holding both the shot and bank pixels passes the test and then loses its land pixels to the write. The dirty pass replays per-block deltas only, so on a straight bank the notch was never repaired. Draws now compose each byte as terrain XOR mask, matching how the player and the other crossing actors already render over mixed terrain, which also keeps a shot visible as a water-coloured hole when it crosses land. Cleanup switches to transition_background=1 and restore_flying_shell_row reads real world bytes. Restoring through fill_world_background_rect is safe over an intact bridge because get_world_terrain_byte models the road as a world layer and rebuilds it, rather than skipping those rows as the water fill had to. The splash uses write_world_sprite_2xn for the same reason: it is an opaque 16-pixel sprite and could straddle the bank edge byte. Reproduced beforehand on the autopilot bench by comparing the display file against the game's own block_bitmap_rows cache: 47 damage events in ~125 s, erased runs of 1 to 5 pixels at bank-edge bytes, persisting up to 4.08 s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tank_shell_target_x is a centre - it is clamped against the lane bounds the same way the jet's centre is - but land_tank_shell copied it into tank_shell_x, which state.asm documents as a left edge and which every consumer treats as one, including the splash draw and its cleanup rect. get_pixel_lane_bounds reserves 16 pixels at the right bank (E = 8*right_col - 16) so a 16-pixel object positioned by its left edge fits. The extra eight pixels ate half that reserve, so the maximum target put the splash's second byte exactly on the right bank edge column. Converting the centre to an edge once at the landing point fixes the alignment and puts the sprite back inside the reserved lane; the left bank always had two bytes of clearance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documented command line hangs before the ZRCP port opens unless the video output is disabled, which reads as a broken emulator rather than a missing flag. Record --vo null next to it, plus the audio flags that keep a host speaker quiet during unattended runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the finished correctness and cleanup items into a Settled section with the evidence, and replaces the "~0-0.5 % frame overruns" premise: measured over three windows per build the bench ranges 0.0 % to 7.8 % overruns on unmodified code, driven by how many actors are live in the sampled window. A single window proves nothing, so the note now says to aggregate several and always run a control build through the same protocol. Adds two follow-ups the investigation exposed: whether resident fixed-X sprites erode banks by the same mechanism the projectiles did (balloon_x and ship0_x are latched at spawn and drawn opaquely, and the scanner used for the projectile bug would have masked exactly this case), and hoisting the per-row terrain fetch the fix introduced, which is redundant because all eight scanlines of a course block share one terrain row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The performance question the correctness fix raised is now measured. The only window pair with an identical actor population puts the cost at about two percentage points of overrun frames; the pooled figures are dominated by one window that caught a bridge destruction, which is a pre-existing worst case, not a cost of the fix. Bridge staging therefore becomes the top performance item on evidence rather than on guesswork. Removes the fetch-hoisting item I had added. Its premise was that a four-row bullet re-resolves the course block four times, but resolve_course_block_index already caches the index and the rows left in it for exactly this access pattern, and the profile confirms most calls take that path. What is left to win is near 1 % of a frame, and taking it needs the cache to be conditional on bridge-band rows and the FUEL column - poor value against code whose correctness was just established empirically. Also records the measurement methodology, including two traps: enemy spawns follow an LFSR that a single extra draw scrambles, so two builds cannot be put in the same scene, and an emulated-time anchor is correlated with the effect being measured because an overrunning frame delays the next halt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Destroying a span sets destroyed_road_active, which update_destroyed_road holds until the band scrolls past the bottom of the playfield - up to 76 frames when a bridge is blown near the top. Throughout that time fill_world_background_rect routed every one of the band's sixteen rows through the per-byte query engine, so any sprite cleanup touching the band paid it. That measured as a sustained half-rate stretch: a profiling window covering one destruction reported 43 % of frame boundaries with no idle halt while still showing 56 % idle overall, the signature of heavy frames alternating with frames spent waiting for the next interrupt. Only two of those rows actually differ from plain terrain - the black edge lines at band rows 1 and 14 - so the other fourteen now take the fast block-bitmap copy. An intact span still owns all sixteen. The Timex build is unaffected: it never treated a destroyed road as special. The fast path copies terrain without the FUEL overlay that the per-byte query applies, so those fourteen rows lose it. That matches what the fast path already does for every other row of the playfield, and FUEL can share a bridge board, so the case needs checking in the emulator alongside this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The destroyed-road slow path was the dominant bridge cost and is fixed, so it moves to Settled. Staging the destroy frame stays on the list at its real size - one or two frames out of the seventy-six the band lives for - with a warning the original plan missed: a half-finished rebuild leaves the band half destroyed while the world model carries one bridge_active bit for all sixteen rows, so the model must be split by row against the restore cursor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spawn_ship0, spawn_ship1 and spawn_helicopter set their active flag through A and then called calc_safe_river_x[_wide], which takes Y in A - so all three asked for a safe X at row 1, above the playfield and two course blocks newer than the row the actor actually appears on. spawn_balloon and spawn_fuel do not have the bug because they compute X before raising the flag. Replaying both candidate formulas against the latched X at every spawn identifies the sampled row unambiguously: ship0 matched row 1 on 15 of 15 spawns and row 16 on none, ship1 16 of 16, the helicopter 9 of 9. Measured over 238 spawns, ten percent of ship spawns put the actor partly on land. This is one of two causes of resident sprites eating terrain; the other is that the safe X is sampled on a single row while the sprites are 8 to 32 scanlines tall and span several course blocks. That one is not fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suspicion is confirmed and much bigger than expected: all five resident actors damage terrain, on both the draw and the cleanup path, 18 513 damaging writes in 200 s, and the damage is permanent whenever the affected column is shared by two adjacent blocks. It is also not cosmetic - collision tests the model rather than the framebuffer, so eroded land stays lethal. Writes down the disproof of the theory I had recorded, because it is the obvious theory and someone will have it again: the river cannot meander into a latched X, since a world-anchored sprite and the course advance together and the block index cancels. The real cause is that the safe X is sampled on one row while the sprites are up to 32 scanlines tall and span several blocks. Also corrects the claim that the FUEL depot was immune. The world query makes other sprites compose over the depot; it does nothing for the depot's own writer, which is the single worst offender. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit dc667fa. It was wrong on both counts. It broke the FUEL depot. The fast path copies block_bitmap_rows raw, without the overlay get_world_background_byte applies, so a depot overlapping a destroyed band lost whole bytes on band row 0 whenever another sprite's cleanup restored the world there. Measured with the only difference between the two builds being this hunk: 41 of 200 frames lost depot pixels, against 0 of 200 with it reverted. And it bought nothing. The window that motivated it still measures 42 % overrun frames afterwards, against 43.2 % before. The premise was wrong: I read the slow band fill as the cost because its symbols appeared only in the bad window, without checking their magnitude - fill_world_background_byte_loop is 0.30 % of instructions, which cannot produce a 43 % overrun rate. The structural reason there was nothing to win: fill_world_background_rect runs only from sprite cleanup, the bridge corridor bans most spawns, and finish_player_transition_direct already switches the player to a full opaque redraw over the band, so a destroyed road usually sees no cleanup at all. The real cost of destroying a bridge is still unattributed. What is known is that it is felt at the explosion itself, not spread over the following second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the claim that the destroyed-road slow path was the bridge cost, and the changelog entry for a change that no longer exists. Writes the attempt up in the rejected list with the numbers, including the reasoning error worth remembering: symbols that appear only in the bad window are a hint, not a cause, and the magnitude has to be checked before building on them. Also corrects the bridge item. Playtesting places the stutter at the explosion rather than in the second that follows, and staging does not need the world model split by row after all - flipping the flags atomically and staging only the bitmap is enough, which is what the agreed crumble effect will do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shots and newly spawned actors were destroying river terrain permanently. The damage was invisible in the sense that eroded land keeps killing the player - collision tests the course model, not the framebuffer - so it reads as dying on open water.
Fixed
Projectiles erased bank pixels. A two-pixel shot was drawn by storing its mask over the whole screen byte and cleaned up by writing plain water, both assuming the byte was entirely river. It is not:
bullet_xis latched when the shot is fired while the river narrows above it, andbullet_hits_backgroundonly asks whether the two lit pixels meet land, so a byte holding both the shot and bank pixels passed the test and then lost its land. The dirty pass replays per-block deltas only, so on a straight bank the notch was never repaired. Draws now composeterrain XOR mask, matching how the player and the other crossing actors already render over mixed terrain, and cleanup restores real world bytes.The tank splash landed eight pixels right of its target. Its aim point is a centre, clamped against the lane like the jet's centre, but
tank_shell_xis a left edge for every consumer. The maximum target therefore put the splash's second byte exactly on the right bank edge column.Ships and the helicopter spawned partly on land. All three raised their active flag through
Aand then calledcalc_safe_river_x[_wide], which takes Y inA- so they placed themselves using a scanline above the playfield.spawn_balloonandspawn_fuelcompute X before raising the flag and were correct.Cleanup
Three routines with no callers deleted; a dead branch in the projectile geometry removed (a zero pixel offset cannot equal seven, so the branch could only reach the result the other test already gave); the block-delta overflow fallback documented as a safety net, including the fact that
render_v3_row_indexedhas one live caller and its island half never executes at all despite thedirty_prefix on its labels.Verification
Reproduced and re-measured in ZEsarUX against the game's own
block_bitmap_rowscache as an independent oracle, with a pre-fix control build and a no-shots noise floor:terrain XOR maskexactlyTried and reverted
Taking the destroyed road off the renderer's per-byte path looked like the bridge cost because its symbols appeared only in the slow window. It was not: that family is 1.16 % of instructions at most, and the change moved the window from 43.2 % to 42.0 % overrun frames while breaking the FUEL depot, which the fast path copies without its overlay. Reverted, with the numbers and the reasoning error written into
docs/TODO.mdso it is not tried again.Not fixed
Resident sprites still eat island and bank edges with their lower rows: the safe X is sampled on one scanline while the sprites are 8 to 32 scanlines tall and span several course blocks. This PR fixes only the spawn-row half. The rest is written up as correctness item 1 in
docs/TODO.md, including the disproof of the obvious theory (the river cannot meander into a latched X - a world-anchored sprite and the course advance together and the block index cancels) and a recommendation to fix placement rather than convert the writers, since placement costs nothing at runtime.docs/TODO.mdalso now carries the profiling methodology, which was harder than expected: two builds cannot be put in the same scene because a single extra LFSR draw scrambles the spawn sequence, and an emulated-time anchor is correlated with the effect being measured because an overrunning frame delays the next halt.🤖 Generated with Claude Code